/*
* This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of
* the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
*
* Copyright (c) 2014 Digi International Inc., All Rights Reserved.
*/
package com.digi.android.wva.fragments;
import android.graphics.Color;
import android.graphics.Paint.Align;
import android.os.Bundle;
import android.os.Handler;
import android.os.Message;
import android.support.v4.app.Fragment;
import android.support.v4.app.FragmentTransaction;
import android.text.TextUtils;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.View.OnClickListener;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.Toast;
import com.digi.android.wva.WvaApplication;
import com.digi.android.wva.model.VehicleData;
import com.digi.android.wva.util.MessageCourier;
import com.digi.wva.async.WvaCallback;
import org.achartengine.ChartFactory;
import org.achartengine.GraphicalView;
import org.achartengine.chart.LineChart;
import org.achartengine.chart.PointStyle;
import org.achartengine.model.SeriesSelection;
import org.achartengine.model.XYMultipleSeriesDataset;
import org.achartengine.model.XYSeries;
import org.achartengine.renderer.XYMultipleSeriesRenderer;
import org.achartengine.renderer.XYSeriesRenderer;
import org.joda.time.DateTime;
import org.joda.time.format.ISODateTimeFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
/**
* The {@link Fragment} which holds and displays the data graph, as well
* as handling incoming data to be plotted.
*
* @author mwadsten
*/
public class ChartFragment extends Fragment {
private static final String TAG = "ChartFragment";
/**
* Amount of time, in minutes (minutes * 60(seconds) * 1000(milliseconds))
* to display on the graph at one time.
*/
private static final int TIMESPAN = 15 * 60 * 1000;
private final MessageHandler mHandler = new MessageHandler(this);
private GraphicalView mChart;
private XYMultipleSeriesDataset mDataset;
private XYMultipleSeriesRenderer mRenderer;
private XYSeriesRenderer mSpeedRenderer, mRpmRenderer;
private double startTime, endTime;
private boolean paused = false;
private boolean subscribed = false;
private boolean isTesting = false;
// We add the last speed and RPM values to the graph after shifting the
// X-axis so that we have a visual indication of the difference between
// them and the new ones.
private VehicleData lastSpeed, lastRPM;
private final Object shiftLock = new Object();
private WvaApplication app;
private static final int MESSAGE_LOOP_INTERVAL = 1000;
private static class MessageHandler extends Handler {
private final ChartFragment fragment;
public MessageHandler(ChartFragment frag) {
fragment = frag;
}
@Override
public void handleMessage(Message msg) {
fragment.processMessages();
}
public void sleep() {
this.removeMessages(0);
sendMessageDelayed(obtainMessage(0), MESSAGE_LOOP_INTERVAL);
}
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
// Don't recreate fragment if activity gets recreated -- just hold
// onto the existing instance.
// This is to ensure that the chart remains in existence.
setRetainInstance(true);
startTime = DateTime.now().getMillis();
// endTime is 15 minutes after startTime
endTime = startTime + TIMESPAN;
buildGraphPieces();
clearDataset();
app = (WvaApplication) getActivity().getApplication();
}
@Override
public void onPause() {
// Ensure messages stop being processed.
mHandler.removeMessages(0);
paused = true;
super.onPause();
}
@Override
public void onResume() {
super.onResume();
if (!subscribed) {
// Will only happen on first start-up.
// Get any messages waiting for the chart. If there are no
// errors, drop any other messages (e.g. "reconnecting"/"connected")
MessageCourier.ChartMessage[] msgs = MessageCourier.getChartMessages();
if (msgs.length == 0 || msgs[0].getError() == null) {
// No errors (they appear at front of queue);
// ignore any other messages.
Log.d(TAG, "No errors on entry.");
} else {
Log.d(TAG, "Encountered error upon entry.");
processMessage(msgs[0]);
return;
}
subscribed = true;
if (!isTesting) {
app.subscribeToEndpoint("EngineSpeed", 10, new WvaCallback<Void>() {
@Override
public void onResponse(Throwable error, Void response) {
if (error != null) {
Log.e(TAG, "Unable to subscribe to EngineSpeed", error);
Toast.makeText(getActivity(), "Unable to subscribe to EngineSpeed: " + error, Toast.LENGTH_SHORT).show();
} else {
Log.d(TAG, "Successfully subscribed to EngineSpeed.");
Toast.makeText(getActivity(), "Subscribed to EngineSpeed.", Toast.LENGTH_SHORT).show();
}
}
});
app.subscribeToEndpoint("VehicleSpeed", 10, new WvaCallback<Void>() {
@Override
public void onResponse(Throwable error, Void response) {
if (error != null) {
Log.e(TAG, "Unable to subscribe to VehicleSpeed", error);
Toast.makeText(getActivity(), "Unable to subscribe to VehicleSpeed: " + error, Toast.LENGTH_SHORT).show();
} else {
Log.d(TAG, "Successfully subscribed to VehicleSpeed.");
Toast.makeText(getActivity(), "Subscribed to VehicleSpeed.", Toast.LENGTH_SHORT).show();
}
}
});
}
}
paused = false;
processMessages();
}
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
// Add chart view to container
if (container == null) {
container = new LinearLayout(getActivity());
}
container.addView(ChartFactory.getCombinedXYChartView(getActivity(), mDataset, mRenderer, new String[] {LineChart.TYPE, LineChart.TYPE}));
//container.addView(ChartFactory.getCombinedXYChartView(getActivity(), mDataset, mRenderer, new String[] {new CombinedXYChart.XYCombinedChartDef(LineChart.TYPE, 0), new CombinedXYChart.XYCombinedChartDef(LineChart.TYPE, 1
return container;
}
/**
* In the process of unit-testing, effectively mocking the WvaApplication
* and making that mock application accessible from this fragment has
* proven to be extremely difficult, if not impossible, because of
* @param isTesting true if testing, false if not testing
*/
public void setIsTesting(boolean isTesting) {
this.isTesting = isTesting;
}
/**
* Repaints the chart view onto the screen. This is done by
* removing the chart view from the layout, calling repaint(),
* and putting the view back into the layout.
*
* <p>This method is protected, rather than private, due to a bug between JaCoCo and
* the Android build tools which causes the instrumented bytecode to be invalid when this
* method is private:
* http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode
* </p>
*/
protected void redrawChart() {
// super.getView() should return NoSaveStateFrameLayout, which
// extends FrameLayout, which extends ViewGroup
ViewGroup l = (ViewGroup)super.getView();
if (mChart == null) {
mChart = ChartFactory.getCombinedXYChartView(
getActivity(), mDataset, mRenderer,
new String[] {LineChart.TYPE, LineChart.TYPE});
mChart.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
SeriesSelection sel = mChart.getCurrentSeriesAndPoint();
if (sel == null)
return;
String series = "SERIES";
switch (sel.getSeriesIndex()) {
case 0:
series = "Vehicle Speed";
break;
case 1:
series = "Engine RPM";
break;
}
String time = ISODateTimeFormat.dateTimeNoMillis().print((long)sel.getXValue());
Toast.makeText(getActivity(),
series + " (" + time + "): " + sel.getValue(),
Toast.LENGTH_SHORT).show();
}
});
}
if (l != null) {
// Ensure that the chart is removed from all views.
try {
if (mChart.getParent() != null) {
//noinspection ConstantConditions
((ViewGroup) mChart.getParent()).removeAllViews();
}
} catch (Exception e) {
e.printStackTrace();
}
l.removeAllViews();
mChart.repaint();
l.addView(mChart);
} else {
// If the getView() result is null, something's up, like
// the activity has been backgrounded.
Log.d(TAG, "redrawChart -- l is null");
}
}
/**
* Create the components that go into the chart.
*
* <p>This method is protected, rather than private, due to a bug between JaCoCo and
* the Android build tools which causes the instrumented bytecode to be invalid when this
* method is private:
* http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode
* </p>
*/
protected void buildGraphPieces() {
if (mRenderer != null || mDataset != null
|| mRpmRenderer != null || mSpeedRenderer != null) {
// Don't want to leak any memory or whatnot.
return;
}
mRenderer = new XYMultipleSeriesRenderer(2);
mDataset = new XYMultipleSeriesDataset();
mSpeedRenderer = new XYSeriesRenderer();
mRpmRenderer = new XYSeriesRenderer();
// Initialize renderer settings
mRenderer.setShowGrid(true);
mRenderer.setFitLegend(true);
// Number of grid lines in either direction by default
mRenderer.setXLabels(0);
mRenderer.setYLabels(10);
mRenderer.setXLabelsAlign(Align.RIGHT);
mRenderer.setYLabelsAlign(Align.RIGHT);
mRenderer.setPointSize(5f);
// AChartEngine output defaults to a black background.
// This doesn't fit with the general WVA color scheme.
mRenderer.setApplyBackgroundColor(true);
mRenderer.setBackgroundColor(Color.WHITE);
mRenderer.setMarginsColor(Color.WHITE);
mRenderer.setAxesColor(Color.DKGRAY);
mRenderer.setLabelsColor(Color.BLACK);
mRenderer.setXLabelsColor(Color.DKGRAY);
mRenderer.setYLabelsColor(0, Color.DKGRAY);
mRenderer.setYLabelsColor(1, Color.DKGRAY);
mRenderer.setGridColor(Color.LTGRAY);
mRenderer.setPanEnabled(false, false);
mRenderer.setZoomEnabled(false, false);
mRenderer.setXAxisMin(startTime);
mRenderer.setXAxisMax(endTime);
mRenderer.setXAxisMin(startTime, 1);
mRenderer.setXAxisMax(endTime, 1);
mRenderer.setYAxisMin(0, 0);
mRenderer.setYAxisMax(100, 0);
mSpeedRenderer.setColor(Color.RED);
mSpeedRenderer.setPointStyle(PointStyle.CIRCLE);
mSpeedRenderer.setFillPoints(true);
mRpmRenderer.setColor(Color.BLUE);
mRpmRenderer.setPointStyle(PointStyle.SQUARE);
mRpmRenderer.setFillPoints(true);
XYSeries speedSeries = new XYSeries("Vehicle Speed");
XYSeries rpmSeries = new XYSeries("Engine RPM", 1);
mDataset.addSeries(0, speedSeries);
mDataset.addSeries(1, rpmSeries);
mRenderer.addSeriesRenderer(0, mSpeedRenderer);
mRenderer.addSeriesRenderer(1, mRpmRenderer);
mRenderer.setYAxisMin(0, 1);
mRenderer.setYAxisMax(10000, 1);
mRenderer.setYTitle("VehicleSpeed");
mRenderer.setYTitle("EngineSpeed", 1);
mRenderer.setYAxisAlign(Align.RIGHT, 1);
mRenderer.setYLabelsAlign(Align.RIGHT, 1);
// Add X-axis labels with time.
Log.d(TAG, "Time range: " + startTime + " to " + endTime);
SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss", Locale.US);
for (double t = startTime; t <= endTime; t += 60*1000) {
String time = fmt.format(new Date((long)t));
Log.d(TAG, "Adding label " + t + ", " + time);
mRenderer.addXTextLabel(t, time);
}
}
/**
* Does what it says on the tin: clears out the data sets.
*/
public void clearDataset() {
if (mDataset != null && mDataset.getSeriesCount() == 2) {
for (XYSeries series : mDataset.getSeries()) {
series.clear();
}
}
}
/**
* Fetch the XYSeries representing the sequence of speed values
* @return the speed series, or null if there is an error or the series
* is itself null
*/
public XYSeries getSpeedSeries() {
if (mDataset != null)
try {
return mDataset.getSeriesAt(0);
} catch (Exception e) {
e.printStackTrace();
return null;
}
return null;
}
/**
* Fetch the XYSeries representing the sequence of RPM values
* @return the RPM series, or null if there is an error or the series
* is itself null
*/
public XYSeries getRpmSeries() {
if (mDataset != null)
try {
return mDataset.getSeriesAt(1);
} catch (Exception e) {
e.printStackTrace();
return null;
}
return null;
}
/**
* Fetch the last vehicle speed data point
* @return the last speed data
*/
public VehicleData getLastSpeed() {
return lastSpeed;
}
/**
* Fetch the last ending rpm data point
* @return the last rpm data
*/
public VehicleData getLastRPM() {
return lastRPM;
}
/**
* Get the X-axis start time
* @return X-axis start time
*/
public double getStartTime() {
return startTime;
}
/**
* Get the X-axis end time
* @return the X-axis end time
*/
public double getEndTime() {
return endTime;
}
/**
* Iterate over {@link MessageCourier#getChartMessages() any pending messages}
* and call {@link #processMessage(com.digi.android.wva.util.MessageCourier.ChartMessage)}
* on each of them.
*
* <p>This method is protected, rather than private, due to a bug between JaCoCo and
* the Android build tools which causes the instrumented bytecode to be invalid when this
* method is private:
* http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode
* </p>
*/
protected void processMessages() {
for (MessageCourier.ChartMessage message : MessageCourier.getChartMessages()) {
if (processMessage(message)) {
// processMessage returning true means that the message was
// an error, and so we should have displayed an error
// dialog; we want to stop processing messages and get ready
// for leaving the chart activity.
return;
}
}
// Stop mHandler loop if paused.
if (paused)
return;
mHandler.sleep();
}
/**
* Process a {@link com.digi.android.wva.util.MessageCourier.ChartMessage}
* and deal with its information. ChartMessages can be either
* notifications of connection error, or they can contain new data.
*
* <p>This method is protected, rather than private, due to a bug between JaCoCo and
* the Android build tools which causes the instrumented bytecode to be invalid when this
* method is private:
* http://stackoverflow.com/questions/17603192/dalvik-transformation-using-wrong-invoke-opcode
* </p>
*
* @param msg the ChartMessage to be processed
* @return true if the message was an error, and we should stop processing
* any more messages
*/
protected boolean processMessage(MessageCourier.ChartMessage msg) {
if (msg.isReconnecting()) {
Toast.makeText(getActivity(), "Reconnecting...", Toast.LENGTH_SHORT).show();
return false;
}
if (TextUtils.isEmpty(msg.getError())) {
// No error message - must be new vehicle data.
VehicleData data = msg.getData();
if (data == null) {
// This is odd!
Log.e(TAG, "processMessage - got message without error or data.");
return false;
}
else {
handleNewData(data);
return false;
}
}
else {
// error is non-null/empty -- there is an error to display
String error = msg.getError();
Log.e(TAG, "Error: " + error);
ConnectionErrorDialog dialog = ConnectionErrorDialog.newInstance("Connection error", error);
FragmentTransaction ft = getActivity().getSupportFragmentManager().beginTransaction();
ft.addToBackStack(null);
dialog.show(ft, "error_dialog");
return true;
}
}
/**
* Process an incoming piece of {@link VehicleData} and
* plot it on the graph.
*
* <p>This method is public so that its behavior can be tested.</p>
* @param incoming the {@link VehicleData} point to plot on screen
*/
public void handleNewData(VehicleData incoming) {
try {
String endpoint = incoming.name;
double value = incoming.value;
long timeMs = incoming.timestamp.getMillis();
if (!isTesting)
Log.d(TAG, "Got new data on " + endpoint + ", value: " + value + ", time: " + timeMs);
// Synchronize on shiftLock so that if two data points come in
// practically simultaneously, we don't shift the view ahead and
// then shift ahead another time.
synchronized (shiftLock) {
if (timeMs > endTime) {
// Shift graph view ahead so current endTime becomes new startTime
startTime = endTime;
endTime = startTime + TIMESPAN;
/*
Wipe out dataset and renderers, so that we can rebuild them
using buildGraphPieces, and they will reflect the new
start and end times.
*/
clearDataset();
mRenderer.clearXTextLabels();
mRenderer.removeSeriesRenderer(mRpmRenderer);
mRenderer.removeSeriesRenderer(mSpeedRenderer);
mRenderer = null;
mDataset = null;
mRpmRenderer = null;
mSpeedRenderer = null;
mChart = null;
buildGraphPieces();
try {
((ViewGroup)getView()).removeAllViews();
} catch (Exception e) {
e.printStackTrace();
}
if (lastSpeed != null && getSpeedSeries() != null) {
getSpeedSeries().add(lastSpeed.timestamp.getMillis(), lastSpeed.value);
}
if (lastRPM != null && getRpmSeries() != null) {
getRpmSeries().add(lastRPM.timestamp.getMillis(), lastRPM.value);
}
}
}
// Add the new data point to the graph.
if ("VehicleSpeed".equals(endpoint)) {
XYSeries series = getSpeedSeries();
if (series != null) {
series.add(timeMs, value);
}
lastSpeed = incoming;
}
else if ("EngineSpeed".equals(endpoint)) {
XYSeries series = getRpmSeries();
if (series != null) {
series.add(timeMs, value);
}
lastRPM = incoming;
} else {
Log.d(TAG, "Unknown graphing endpoint: " + endpoint);
}
// Redraw the chart on-screen so the new data point is visible.
redrawChart();
} catch (Exception e) {
e.printStackTrace();
}
}
}